[client][connector] Pass an explicit deadline through the KV cache I/O path - #280
[client][connector] Pass an explicit deadline through the KV cache I/O path#280lpdink wants to merge 9 commits into
Conversation
| @@ -0,0 +1,134 @@ | |||
| # Client SDK I/O 契约(Timeout / 取消 / Buffer 生命周期) | |||
|
|
|||
| > 本文件是 `docs/tasks/safe-timeout/01-contract.md` 的仓库内副本(任务卡约定: | |||
There was a problem hiding this comment.
决策细节不应该泄露到MR中,确保添加的文档具备高信息密度
There was a problem hiding this comment.
这个文档应该完全重写,介绍这里的设计和约定;另外,引入的配置项,含义,默认值也写进来。
|
|
||
| // 可控 slow/fake SDK(W5 验收测试替身,仅测试使用)。 | ||
| // | ||
| // 能力(对应 15-W5-tests.md §2): |
There was a problem hiding this comment.
总的来说,注释充当文档是可以的,但是要简洁清晰地说明在测试什么,预期是什么。不要泄露文档,以及“契约 §2(2)”
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 2847f20035
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| int completed_io_count = | ||
| usrbio_api_->Hf3fsWaitForIos(ior, cqes, submit_io_count, submit_io_count, abs_timeout_ptr); |
There was a problem hiding this comment.
Drain submitted 3FS I/O before releasing buffers
When a deadline is present, this finite abs_timeout_ptr lets Hf3fsWaitForIos return with fewer completions than submit_io_count; that path returns false, and DoRead/DoWrite then call ReleaseIovIor, which frees the shared iov buffer and destroys the IOR while the uncompleted 3FS requests may still finish asynchronously. This only shows up on HF3FS operations that hit the new timeout path, but it can turn a bounded timeout into use-after-free/corrupted reads or writes; the timeout path needs to cancel/drain or otherwise wait for all submitted requests before releasing those resources.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Real issue, and the resolution is deliberate: on timeout we leak the iov/IOR rather than releasing them.
usrbio offers no cancel/abort for a submitted IO, so once hf3fs_submit_ios has happened there is no way to revoke an in-flight write. Destroying the iov while the DMA can still land would be a use-after-free into the caller's pinned buffer. A bounded leak is strictly preferable, so the timeout path intentionally does not free them, HF3FS is declared soft in the履約 matrix, and this is written into Known Limitations rather than implied away.
A closely related bug on this same path was wrong and is fixed in dadff14: an already-expired deadline used to pass a null abs_timeout to hf3fs_wait_for_ios, which means wait-forever — so the case where we most needed to bail out instead hung indefinitely. Now "no deadline" (null) and "expired" (zero timeout) are distinct states.
| if (SdkDeadline::Expired()) { | ||
| KVCM_LOG_WARN("do read skipped, deadline expired, file: %s, iovs size: %zu", filepath_.c_str(), iovs.size()); | ||
| return false; | ||
| } |
There was a problem hiding this comment.
Preserve HF3FS timeout errors through the SDK layer
This new deadline branch returns only false, so callers cannot distinguish it from ordinary I/O failure: Hf3fsSdk::Get/Put(const DataStorageUri&, ...) maps the failed Read/Write to ER_SDKREAD_ERROR/ER_SDKWRITE_ERROR, and the batch methods propagate those instead of ER_SDK_TIMEOUT. When HF3FS times out inside DoRead, DoWrite, or WaitIos just before the wrapper observes the future as ready, clients and timeout metrics see a storage error rather than a timeout; propagate an explicit timeout status from these paths.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Timeouts are distinguishable at the layer that matters: SdkWrapper returns ER_SDK_TIMEOUT, and the tests assert on it specifically (e.g. ASSERT_EQ(ER_SDK_TIMEOUT, sdk.Put(...))).
I deliberately did not thread a distinct timeout code up through the Hf3fsUsrbioClient::Read/Write booleans. Those return bool today, and widening them would ripple through the whole HF3FS layer for information the caller doesn't act on differently — the recovery for "timed out" and "I/O failed" is identical here, and the timeout is already visible in the logs plus the wrapper-level code. Happy to revisit if a caller ever needs to branch on it.
| auto elapsed_ms = std::chrono::duration_cast<std::chrono::milliseconds>( | ||
| std::chrono::steady_clock::now() - start) | ||
| .count(); | ||
| SdkIoStats::Instance().OnTimeout(task.sdk_type, is_get, elapsed_ms, /*done=*/i, tasks.size()); |
There was a problem hiding this comment.
Record unsafe Mooncake returns on wrapper timeouts
When the timed-out task is Mooncake and it is currently inside mooncake_client_get/put, this wrapper timeout is the path that returns to the caller while RDMA may still access caller buffers; the per-key OnUnsafeReturn in MooncakeSdk only runs before issuing the next key, so a single slow key or the last key returns with only timeout_count and no unsafe-return log/counter. That hides precisely the soft-contract exposure this change is trying to measure; count/log OnUnsafeReturn here for SdkType::MOONCAKE.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Logged, in one line, on exactly that path — LogSoftTimeout fires when the wrapper returns while Mooncake RDMA may still touch caller buffers.
Note this function is also where codex found a hard compile error, fixed in dadff14: it carried a redundant remaining_ms argument (always <= 0 by construction, since it's only called post-deadline) whose %d format didn't match size_type. Dropping the parameter fixed the build and let me delete two duplicate KVCM_LOG_WARN lines. I kept the log deliberately terse rather than accumulating counters — this is a soft backend by declaration, and the fact belongs in the履約 matrix and Known Limitations, not in a stats class on the production path.
| } | ||
|
|
||
| if (!WaitIos(handle->ior_handle, submit_io_count)) { | ||
| if (!WaitIos(handle->ior_handle, submit_io_count, /*for_read=*/false)) { |
There was a problem hiding this comment.
Check HF3FS deadline before each write batch
After this bounded wait succeeds, the loop immediately continues and can prepare/submit the next 3FS write chunk without rechecking SdkDeadline::Expired(). For large blocks split across multiple 1 MB writes, the previous batch can consume the remaining budget, then the next batch is still issued after the deadline and only times out at the following wait, so a timed-out Put may keep mutating the remote file after its budget is gone. Add an admission check before preparing/submitting each batch.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Implemented — the deadline is re-checked per block/batch, not just once before the loop, so a large block split across multiple 3FS chunks stops at the next chunk boundary instead of running to completion.
SdkTimeoutContractTest covers this explicitly with a per-block admission mode that verifies the operation stops midway rather than either finishing everything or bailing out entirely.
There was a problem hiding this comment.
Review Summary
This PR is well-architected and thoroughly documented. The core design — pushing the deadline to the layer that actually writes the buffer, with the wrapper reduced to an admission gate — correctly addresses the root cause of the data corruption / UAF class of bugs. I found no blocking issues.
Design Assessment
Deadline propagation (SdkDeadline): thread_local + RAII Scope is a clean choice. The save/restore semantics handle nesting correctly, and returning false/-1 when unset preserves backward compatibility for direct SDK calls (e.g., unit tests). The steady_clock base is correctly converted to CLOCK_REALTIME for the HF3FS abs_timeout path.
Wrapper admission check: The key fix — checking now >= deadline at task start rather than relying on a stop flag — correctly prevents queued tasks from issuing I/O after the caller has returned. The removal of drain() is sound, provided the thread pool's futures are promise-backed (non-blocking destructor), which the code documents and asserts.
Per-backend contracts: The hard/soft distinction is pragmatic and honest. LocalFile (GpuStreamDrainGuard RAII) and HF3FS (skip CopyIovs on timeout, data lands in own shm) genuinely achieve hard contract. Mooncake's soft contract is well-bounded — per-key admission reduces exposure from 128 blocks to ≤1, and OnUnsafeReturn provides the observability needed to make a future staging decision based on data rather than speculation.
F3 ordering fix (SplitByPath indices + index-based writeback in LocalFileSdk::Put): This is a real correctness fix. The old insert-at-end approach depended on unordered_map iteration order, which is non-deterministic for interleaved multi-path inputs. The new resize() + indices[k] assignment is correct.
GpuStreamDrainGuard: Verified that local_file_sdk.cc includes cuda_util.h, whose CHECK_CUDA_ERROR only logs (no throw/abort), so using it in a destructor is safe. Construction ordering (after MmapHelper) ensures stream sync precedes munmap/cudaHostUnregister — correct.
Test coverage: 30+ new tests covering running timeout, queued rejection, hard-contract buffer safety, soft-contract observability, deadline propagation, GPU stream draining, and F3 ordering. The --runs_per_test=5 run with no flaky failures is good evidence of stability.
Minor Observations (inline comments left)
-
mooncake_sdk.cc:28— Thedone=%zu/%zulog field name implies "completed" but for a soft-contract backend it means "issued" (RDMA may still be in-flight). The trailing message clarifies, but the field name alone could mislead an operator during incident triage. -
hf3fs_usrbio_client.cc:351— Theclock_gettimefailure fallback tonullptr(infinite wait) technically re-opens the unbounded-wait hole this PR closes. It's a documented trade-off andclock_gettimefailure is vanishingly rare, but a conservative finite fallback would preserve boundedness even in the degenerate case.
Pre-existing concerns (acknowledged in PR, not regressions)
- HF3FS
iordestroyon in-flight I/O — pre-existing, data lands in own shm. Tracked in open questions. - Mooncake upstream cancel semantics — accepted as soft contract with observability-driven decision criteria.
- PACE timeout chain alignment — pending PACE team confirmation.
🤖 Generated by Qoder
| int64_t elapsed_ms, | ||
| int64_t remaining_ms) { | ||
| KVCM_LOG_WARN("mooncake %s timeout: backend=mooncake op=%s done=%zu/%zu refused_block_idx=%zu key=%s " | ||
| "caller_buffer=%p caller_buffer_size=%zu elapsed_ms=%lld remaining_ms=%lld " |
There was a problem hiding this comment.
Minor observation: the done=%zu/%zu field name is slightly misleading for a soft-contract backend. For Mooncake, done represents the count of issued blocks, not completed blocks — the issued RDMA transfers may still be in-flight at return time (as the trailing "already-issued blocks [0,%zu) may still be written" message correctly states). An operator debugging an incident who reads only done=5/10 from the first half of the log might incorrectly assume 5 blocks completed safely. Consider renaming to issued=%zu/%zu or adding a clarifying inline note like done(issued)= to avoid this ambiguity.
🤖 Generated by Qoder
There was a problem hiding this comment.
Good catch on the semantics — for Mooncake those blocks are issued, not completed, since the RDMA may still be in flight at return.
The log line no longer claims otherwise: LogSoftTimeout states plainly that already-issued blocks may still be written, which is the point a reader needs, and I removed the surrounding duplicate warnings that were restating the same thing with a misleading count. The issued vs completed distinction is also what makes Mooncake soft in the履約 matrix.
| #endif | ||
| } | ||
|
|
||
| // 验收 1(01-contract.md §6 第1条 + 11-W1 §4.1):已过期的 deadline 必须让 Get |
There was a problem hiding this comment.
Fixed, and I did a full-PR sweep for this category in dadff14 rather than only the flagged lines. Comments that narrated why I chose something are gone; what's left states the invariant. Concrete example — deadline_util.h went from a 4-line justification to:
// deadline_ms:绝对时间点(steady_clock 毫秒),0 = 无 deadline。Where a comment was arguing a fact (the Python/C++ clock identity), I replaced the argument with a test.
| FreeBuffers(buffers); | ||
| } | ||
|
|
||
| // 验收 2(可选但推荐):很短但未过期的 deadline,构造"搬了一部分就超时"的中间态。 |
There was a problem hiding this comment.
Removed. That whole family of comments — restating what the next line plainly does, or referencing task-card documents that don't exist upstream (15-W5-tests.md, 00-context.md, connector-dataflow.md) — is cleaned out in dadff14. Verification greps over the diff for those references come back empty.
| @@ -0,0 +1,575 @@ | |||
| // W5 验收测试:可控 slow/fake SDK 覆盖超时与 buffer 生命周期契约 | |||
There was a problem hiding this comment.
Same treatment applied here, and swept across the PR rather than spot-fixed. Also removed stale 契约 §N pointers that were wrong as well as noisy — the committed contract doc has only sections 1-4, so §6 referred to nothing at all.
| // - 绝对时间点,单位微秒,基于 std::chrono::steady_clock(内核 CLOCK_MONOTONIC)。 | ||
| // - 0 表示「调用方不施加 deadline」:SDK 内部不做准入/等待限制(SdkWrapper 层 | ||
| // 仍会用 wrapper 级 timeout_config 兜底,见 sdk_wrapper.cc)。 | ||
| // - 时钟一致性(已实测):Python time.monotonic_ns()//1000 与 C++ steady_clock |
There was a problem hiding this comment.
加个测试来断言Python侧和C++侧的偏差很小;
如果一个事情可以用测试来fix,就不要写注释;
There was a problem hiding this comment.
这个测试加了么?加了就简化一下这里的注释。生产路径的注释都要保持高信息密度。
There was a problem hiding this comment.
The test is added now, so the comment is gone — dadff14.
New test (in SdkTimeoutContractTest, i.e. off the production path as you asked):
[ RUN ] SdkTimeoutContractTest.TestPythonAndCppShareSteadyClock
[ OK ] SdkTimeoutContractTest.TestPythonAndCppShareSteadyClock (10 ms)
The header dropped from 4 comment lines to 1:
// deadline_ms:绝对时间点(steady_clock 毫秒),0 = 无 deadline。The clock-identity claim used to be argued in prose; it is now asserted by a test, which is the right place for it.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b41ca3624a
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| return transfer_client_->LoadKvCaches(uri_str_vec, block_buffers); | ||
| // ManagerClient 层不感知 deadline(契约见 PLAN 2.2):传 0,由 SdkWrapper 级 | ||
| // timeout_config 兜底(等价 PR280 之前行为)。 | ||
| return transfer_client_->LoadKvCaches(uri_str_vec, block_buffers, /*deadline_us=*/0); |
There was a problem hiding this comment.
Propagate a real deadline for ManagerClient I/O
Fresh evidence in this revision is that the ManagerClient/RTP path explicitly passes 0 here, which disables the new backend-level deadline checks even though SdkWrapper still returns at its fallback timeout_config deadline. For callers using this public API, a LocalFile/HF3FS task can continue after ER_SDK_TIMEOUT and keep reading or writing the caller-owned buffer, recreating the corruption window this change is meant to close; compute and pass the wrapper fallback deadline down instead of 0 for these calls.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Intentional, and unchanged: ManagerClientImpl exposes no deadline to its callers, so passing 0 preserves exactly the pre-PR behaviour for that path — the static timeout_config budget still applies inside SdkWrapper, which continues to return at its internal deadline. Nothing is disabled that used to be enabled; there was never a caller-supplied deadline here.
Inventing a deadline at this layer would mean fabricating a buffer-lifetime promise on behalf of a caller who never made one. Since the contract is "whoever owns the buffer sets the deadline", and this layer doesn't own it, 0 is the correct value.
With deadline_ms now defaulting to 0 in the header this is also no longer visible as an explicit magic argument:
// 本层不暴露 deadline:沿用 client 配置的静态超时预算。
return transfer_client_->LoadKvCaches(uri_str_vec, block_buffers);|
|
||
| // submit_io_count 达到最大或者没得读 | ||
| if (!WaitIos(handle->ior_handle, submit_io_count)) { | ||
| if (!WaitIos(handle->ior_handle, submit_io_count, deadline_us, /*for_read=*/true)) { |
There was a problem hiding this comment.
Thread deadline_us through HF3FS helpers
In HF3FS-enabled builds, this new deadline_us argument is referenced inside ReadFrom3FS, but the helper signature still does not receive it and DoRead still calls ReadFrom3FS(handle, segments). The same pattern appears in WriteTo3FS, so any build with ENABLE_HF3FS fails before the timeout path can be tested; pass deadline_us through both helper declarations, definitions, and call sites.
Useful? React with 👍 / 👎.
| lease_deadline_us = ( | ||
| time.monotonic_ns() // 1000 + int(self.write_timeout_seconds * 1_000_000) | ||
| if result is not None | ||
| else 0 |
There was a problem hiding this comment.
Don’t send absolute monotonic deadlines across hosts
When TP ranks or scheduler/worker run on different hosts, this broadcasts rank 0’s time.monotonic_ns() absolute timestamp and later compares it with another process’s local steady clock when building the SaveKvCaches deadline. Monotonic-clock epochs are only meaningful on the host that produced them, so a multi-node job can see the lease as already expired (dropping writes) or far in the future (writing after KVCM may reclaim the URI); broadcast a relative remaining lease duration or recompute a local absolute deadline on each receiver.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Agreed, and no absolute deadline crosses a host or process boundary anywhere in this PR any more.
The broadcast is removed; each worker computes its own deadline from its own clock and its own config. Note the originally suggested fix — broadcasting a relative remaining time — was rejected for a different reason: every worker already knows the config, so transmitting it saves nothing while still coupling processes.
The trade-off is explicit and documented: cross-machine clock skew would break the happy path (a skewed worker deciding everything is already expired and refusing all I/O), whereas lease overrun only manifests in the timeout path. Choosing per-process computation accepts the latter to eliminate the former, and it's recorded in Known Limitations.
| # 绝不可打破 —— 与 worker 侧的 DDL_自律取 min 后才是最终 deadline)。 | ||
| # 时钟源:time.monotonic_ns() 与 C++ steady_clock 同为 CLOCK_MONOTONIC(已实测), | ||
| # 直接传 us 数值比较,无需校正。 | ||
| lease_deadline_us = time.monotonic_ns() // 1000 + int(self._write_timeout_seconds * 1_000_000) |
There was a problem hiding this comment.
Use the server lease expiry for write deadlines
This computes the write lease deadline from local time after start_write_cache returns, but the manager starts the session timeout when WriteLocationManager::Put runs during StartWriteCache and also caps the requested timeout. When start_write_cache is slow or the configured timeout exceeds the server cap, the client can pass a SaveKvCaches deadline that is later than the real lease, allowing writes after KVCM has expired and reclaimed the locations; have the server return the authoritative expiry/remaining TTL or subtract the elapsed/capped duration before passing the deadline.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
这是已知的,我们暂时不修改kvcm server端的行为,我们知道这里有gap,但是我们无法完全解决这个问题;客户端怎么可能完全知道kvcm从什么时候开始计数?通信总是需要时间的,所以这是被接受的;
There was a problem hiding this comment.
Understood, treating it as accepted and unchanged. No client-side attempt to model when KVCM starts counting.
I've recorded it in docs/design/client_sdk_io_contract.md under Known Limitations so the residual gap is explicit for future readers rather than folklore: the client's deadline is anchored to its own clock at the start_write_cache response, so the RPC round-trip is inherently outside the client's knowledge, and the lease can be marginally exceeded by that amount.
There was a problem hiding this comment.
Fair — that thread was about whether a deadline computed in the scheduler stays valid once it reaches the worker. It's moot now: no deadline crosses a process boundary in this PR, each process computes its own from its own clock, and the cross-machine clock caveat is recorded in Known Limitations. Nothing left to act on here.
There was a problem hiding this comment.
Short version: the concern was that the deadline was being computed too early relative to where the buffer is actually used, so queueing/blocking time got charged against the SDK's transfer budget.
That's now fixed on both paths, in the two different ways their constraints demand:
- Read: computed inside
vllm/data_transfer.load_task, right afteralloc_buffer_idx_blockingreturns — the clock starts when the buffer being protected exists, so pool waiting no longer eats the budget. - Write: still computed in the worker before task submission, because it must stay anchored to the KVCM lease (
start_write_cache), not to buffer acquisition —save_taskblocks onkvcache_ready_event.wait()first, and starting the clock after that would drop the lease constraint entirely.
Hence the deliberately asymmetric signatures: load_task takes no deadline_ms, save_task does.
| } | ||
| std::shared_ptr<SdkInterface> sdk; | ||
| switch (type) { | ||
| auto creator_it = custom_creators_.find(type); |
There was a problem hiding this comment.
测试代码不要耦合到生产函数路径里面,没别的办法写出不耦合的版本么?除非,生产路径会有custom_creators么?如果没有,就不要放进来。
There was a problem hiding this comment.
考虑在测试代码里覆写/装饰CreateSdk?方法可以很灵活,而不是把测试相关的代码放到生产路径。
| using SdkCreator = std::function<std::shared_ptr<SdkInterface>( | ||
| const std::shared_ptr<SdkBackendConfig> &, const std::shared_ptr<StorageConfig> &)>; | ||
| void RegisterCustomCreatorForTest(DataStorageType type, SdkCreator creator); | ||
| void ClearCustomCreatorsForTest(); |
There was a problem hiding this comment.
不应该为测试方便而在生产路径增加方法;
There was a problem hiding this comment.
Removed. No test-only surface remains on the production path.
Specifically dropped: the sdk_factory test-registration hook, plus the SdkDeadline / SdkIoStats / TimedTask types that existed largely to make assertions convenient. Fault injection is now done the ordinary way — a virtual method overridden by a test subclass (fake_slow_sdk.h, which lives under test/).
| auto &group = groups[uri.GetPath()]; | ||
| // 记录原始下标:下标是 block 的唯一身份,消费方(如 Put 回填 actual_remote_uris) | ||
| // 必须按 indices 保序,禁止依赖 unordered_map 迭代序。 | ||
| group.indices.push_back(i); |
There was a problem hiding this comment.
奇怪,怎么带进这个提交里了,谁修的?和本提交无关,已经在 #276 中修掉了;难怪有冲突,先rebase一下origin/main吧。
There was a problem hiding this comment.
You were right, and this turned out to be the most serious problem in the branch. Fixed by rebuilding it from origin/main.
What had happened: an intermediate rebase applied a diff computed against the old fork point onto a newer main, which reverse-applied work that was already merged — #244 (GC), #272, and #259/#264, about 3353 lines. Your comment about the conflicts being suspicious was the thread that unravelled it.
Recovery was a clean branch from the latest origin/main plus a whitelisted, file-by-file replay of only this PR's changes, adjudicating each hunk individually (down to splitting a single line in docs/README.md that mixed "add contract index" with "delete GC index").
The diff is now confined to five areas — kv_cache_manager/client, kv_cache_manager/py_connector, open_source/kv_cache_manager, docs/design, docs/README.md — and I check this mechanically before每 push: any path under manager/, meta/, optimizer/, service/, metrics/, protocol/, package/ or integration_test/ means something was reverted by accident. Current run: no out-of-scope paths, and no production function or test from main missing.
| // 保序契约:下标是 block 的唯一身份 —— block_keys[i] ↔ locations[i] ↔ buffers[i] ↔ | ||
| // actual_uris[i] 必须一一对应。实现方(如 LocalFileSdk::Put)分组处理后必须按 indices | ||
| // 回填实际位置,禁止依赖 unordered_map 的迭代序(见 docs/design/client_sdk_io_contract.md)。 | ||
| std::vector<size_t> indices; |
There was a problem hiding this comment.
Removed from this PR — the diff is now restricted to client, py_connector, open_source/kv_cache_manager, docs/design and docs/README.md, and I verify that path whitelist before pushing. Anything else that crept in was collateral from the bad rebase described in the #276 thread, and is gone.
|
|
||
| // 尝试获取剩余毫秒。返回 true 表示有剩余(不存在 deadline 或不存在未过期时返回 false)。 | ||
| // 调用方仅在返回 true 时使用 out 值(剩余毫秒,>0)。 | ||
| inline bool TryRemainingMs(int64_t deadline_ms, int64_t &out) { |
There was a problem hiding this comment.
我们在这里没有很好的办法,怎么写怎么不对,上一个版本是用了int64直接作为返回值,约定-1超时,0无限,+有限,也很反模式,现在这个写法,也行吧。fine; 最C++ style的是什么?
There was a problem hiding this comment.
I went ahead and changed it, because your earlier point about the two-meanings-of-false bug convinced me it was worth it:
inline std::optional<int64_t> RemainingMs(int64_t deadline_ms);To answer the question directly — std::optional is the idiomatic choice here, and the ranking is roughly:
std::optional<int64_t>— "a value, or nothing". No sentinel to memorise, and.value_or(0)expresses "treat absence as expired" at the one call site that wants that. What it does not give you is a reason for the absence.std::expected<int64_t, DeadlineState>(C++23) — would be the most precise: absence carries why (kNoDeadlinevskExpired). This is precisely the distinction whose collapse caused the HF3FS unbounded-wait bug. Not available to us on C++17, and honestly overkill for a two-state enum.std::chrono::millisecondsinstead of rawint64_t— type-safe against unit mix-ups, which this codebase has already been bitten by (an earlier iteration of this PR shipped aDeadlineFromNowMsthat multiplied by 1000). I did not do it here because the value crosses the pybind boundary as a plain integer, and wrapping only the internal half would just add conversions.- The
bool+ out-param it used to be — worst of the four: the failure case is unlabelled, so callers guess. - Magic
int64_tsentinels (-1timeout /0infinite) — the previous version, agreed it was an anti-pattern.
So: optional today, expected if we ever move to C++23. Note the "which kind of absence" question does not disappear with optional — I kept DeadlineExpired() alongside it for the caller that genuinely needs to tell the two apart, and documented that pairing in one line.
| if (!ReadFrom3FS(handle, segments, deadline_ms)) { | ||
| // Deadline 过期导致 WaitIos 超时 → 3FS 可能仍有 I/O 在飞。 | ||
| // 释放 iov/ior 会导致已提交的 3FS 请求写已释放内存(UAF)。 | ||
| // 泄漏优于 UAF,详见 known limitations。 |
There was a problem hiding this comment.
3fs真的没有取消语义么。。。如果有,就不会陷入这个问题了。
这里比较危险的是完全失效,这在宕机场景是有完全可能的,对于这种staging方案,我们此前的方案是维护一个TTL,例如可配置的100s,从而避免极端场景下,3fs后端完全失效。似乎没有在这个提交中落地,也许要考虑一下。这可以缓解3fs完全失效的问题...但是又引入了可能的,因为TTL 100s是拍出来的,我们不知道会不会脏数据。这要决策一下。从另一个角度讲,实际上,如果iov全丢了,应该重启该推理引擎来规避,而不是脏数据,也说得通;
There was a problem hiding this comment.
不处理这个意见,我和maintainer讨论一下。
There was a problem hiding this comment.
Leaving this one as-is per your follow-up — happy to revisit once you've talked to the maintainer.
For the record on the narrow factual question: usrbio exposes no cancel/abort for a submitted IO, so once hf3fs_prep_io + hf3fs_submit_ios have happened there is no way to revoke the in-flight write. Hence leaking the iov/IOR on timeout rather than destroying them — bounded leak over a use-after-free into the caller's pinned buffer. HF3FS is therefore declared soft in the履约 matrix, and this is written up in Known Limitations rather than glossed over. A TTL-based reclaim like the one you describe would fit on top of that without changing the SDK contract.
One related bug in this area was real and is fixed in dadff14: an already-expired deadline used to pass a null abs_timeout to hf3fs_wait_for_ios, which means wait-forever — so the very case where we most wanted to bail out was the one that hung. Details in my reply to codex on that thread.
| # 读路径无租约:deadline 只含自律项(DDL_自律 = T_submit + sdk_get_timeout_ms)。 | ||
| # T_submit = 任务提交到 io_executor 的时刻(传输线程池可能排队,deadline 须在 | ||
| # 提交前算好)。time.monotonic_ns() 与 C++ steady_clock 同为 CLOCK_MONOTONIC。 | ||
| load_deadline_ms = deadline_ms_from_now(self._extra_config.sdk_get_timeout_ms) |
There was a problem hiding this comment.
读路径不应该提前算,读路径的风险不在和KVCM的契约,而是在超时以后,storage sdk仍然写pinned;
而这个buffer是在transfer client里面申请的。不要在这里算,这平白无故地缩短了读路径的超时时间。如果池子的task排队了的话。
There was a problem hiding this comment.
因为,只有申请了buffer的人才关心他们的buffer有没有被污染,VLLM connector没有申请buffer,它不应该关心这个概念;让transfer client区关心这个问题,它申请了buffer;它不希望自己的buffer被污染,
There was a problem hiding this comment.
我的意思是vllm/data_transfer.py去关心。不是指C++的data transfer client;
There was a problem hiding this comment.
Agreed, moved in dadff14. Your framing — the read-path risk is not the KVCM contract but the storage SDK still writing into pinned memory after the timeout — is the correct one, and computing the deadline here was wasting budget for no benefit.
The connector no longer computes anything; those lines are simply deleted. The deadline is now taken inside data_transfer.load_task, immediately after the buffer is in hand:
copy_buffer_indices = self._copy_buffer_allocator.alloc_buffer_idx_blocking(len(remote_uris))
copy_buffers = self._copy_buffer_allocator.get_buffer_by_idx(copy_buffer_indices)
# buffer 到手后才起算:本函数是 buffer 的所有者,deadline 之后可安全重用它们。
deadline_ms = deadline_ms_from_now(self._extra_config.sdk_get_timeout_ms)This matters concretely because alloc_buffer_idx_blocking blocks: under pool pressure the old placement charged both the executor queueing delay and the buffer wait against the SDK's 15s, so a busy instance would hand the SDK a budget already partly spent, or even an expired one. Now the clock starts when the resource being protected actually exists.
v1_connector.py has exactly one deadline_ms_from_now call left, on the write path, for the reasons in your other comment.
There was a problem hiding this comment.
This is the cleanest statement of the ownership rule and I've made the code follow it: whoever allocates the buffer is the only party that needs to reason about it being touched.
The vLLM connector allocates nothing on the read path, so it now says nothing about deadlines there — the parameter is gone from load_task's signature rather than being threaded through as 0. The owner (data_transfer.load_task) computes its own.
It also makes the layering honest: the deadline is a property of the buffer's lifetime, so it belongs with the code that controls that lifetime. Passing it down from a layer that never owned the memory was the thing that felt off.
There was a problem hiding this comment.
Understood — kv_cache_manager/py_connector/vllm/data_transfer.py, the Python module, not the C++ TransferClient. That is where it now lives (DataTransferManager.load_task), since that function is what calls CopyBufferAllocator and therefore owns the buffer.
No C++ signature changed for this; LoadKvCaches still just receives whatever absolute deadline its caller decided on.
| end_idx = min(len(blocks_idx), i + per_task_size) | ||
| task_remote_uris = all_remote_uris[i:end_idx] | ||
| task_block_token_indices = blocks_idx[i:end_idx] | ||
| deadline_ms = deadline_ms_from_now(self._extra_config.sdk_put_timeout_ms) |
There was a problem hiding this comment.
写路径就是另外一回事了,由于有和KVCM之间的30s契约,我们必须在尽可能靠近self._manager_client.start_write_cache(request)的地方算DDL,同时,必须在worker上,避免多机问题。这里是最近的吗?
There was a problem hiding this comment.
Both of your constraints hold, and I moved it slightly closer in dadff14 — though not as close as start_write_cache itself.
Where it is now: still in _build_save_tasks (worker process, satisfying constraint 2), but hoisted out of the per-task loop so it is computed once instead of drifting later for each subsequent task:
# 写路径受 KVCM 租约约束,DDL 须尽可能靠近 start_write_cache 的响应时刻,
# 且必须在 worker 进程内计算(避免跨进程时钟问题)。
deadline_ms = deadline_ms_from_now(self._extra_config.sdk_put_timeout_ms)
for i in range(0, len(blocks_idx), per_task_size):
...Why not push it into save_task the way I did for the read path: save_task begins with kvcache_ready_event.wait(), i.e. it blocks until the GPU has finished producing the KV cache. Starting the clock after that wait would discard the lease constraint entirely — the lease is already ticking on the KVCM side regardless of whether we are still waiting on compute. So the write path deliberately keeps the earlier, more pessimistic anchor.
Why not right next to start_write_cache: that call happens in the scheduler process, and its result reaches the worker through connector metadata. Computing there and shipping the value across would violate constraint 1 and reintroduce the cross-process clock hazard you flagged on the sglang path. So the worker recomputing it from its own clock is the closest we can get without crossing a process boundary — the residual gap (scheduler->worker hop) is bounded by the metadata round-trip and is the known limitation documented in the design doc.
|
|
||
| def __repr__(self): | ||
| return f"TairKvCacheConnectorMetadata(requests={self.requests})" | ||
|
|
There was a problem hiding this comment.
Reverted in dadff14 — metadata.py is now byte-identical to origin/main.
It was pure noise (a docstring indent shift) and it had also silently dropped the trailing newline, which made the file end with \ No newline at end of file. Sorry for the review overhead.
| return self._io_executor.submit(func, *args, **kwargs) | ||
|
|
||
| def load_task(self, multi_result: MultiResult, task_idx, remote_uris, block_token_indices): | ||
| def load_task(self, multi_result: MultiResult, task_idx, remote_uris, block_token_indices, deadline_ms): |
There was a problem hiding this comment.
也就是在这个函数里面算,不要有参数对称强迫症;这个抽象在vllm包内部,完全是可以接受的。在buffer申请下来以后算。
There was a problem hiding this comment.
Done exactly that in dadff14 — computed inside the function, after the buffer is acquired, and no symmetry for symmetry's sake.
load_task lost the parameter outright:
def load_task(self, multi_result, task_idx, remote_uris, block_token_indices):while save_task keeps receiving deadline_ms from the caller, because on the write path the deadline is anchored to the start_write_cache response rather than to the buffer. The two signatures are deliberately asymmetric now — they encode two genuinely different constraints, and this abstraction is private to the vllm package anyway.
| save_indices = self._parse_block_mask(block_mask, len(block_keys)) | ||
| assert len(store_locations) == len(save_indices) | ||
|
|
||
| metadata.save.append((store_locations, [block_ids[block_pos] |
There was a problem hiding this comment.
回退一下代码格式化,给review带来了负担。
There was a problem hiding this comment.
Reverted in dadff14 — the metadata.save.append(...) line wrapping is back to its original form.
I also removed a now-dead import time from this file (leftover from before it switched to deadline_ms_from_now), so the remaining diff here is only the deadline change itself.
|
|
||
| int timeout_ms = wrapper_config_->timeout_config().put_timeout_ms(); | ||
| ec = RunWithTimeoutParallel(OpType::PUT, std::move(tasks), timeout_ms); | ||
| ec = RunWithTimeoutParallel(OpType::PUT, std::move(tasks), deadline, timeout_ms); |
There was a problem hiding this comment.
这里为什么既有deadline,又有timeout_ms啊,删掉timeout_ms,我不理解,它看起来只被用于计算start了,用get now,可以不?为什么要用ddl和timeout_ms去计算start呀。
There was a problem hiding this comment.
You're right on both counts — deleted in dadff14, and now() is not just "acceptable", it's the only correct option.
The old line was:
auto start = deadline - std::chrono::milliseconds(timeout_ms);which reconstructs the start time by walking backwards from the deadline. That was already fragile, and this PR broke it outright: once deadline can be the caller's value (because we take the min), subtracting our own static budget yields a start that never happened. Every elapsed_ms computed from it — the timing in the warn logs — was fiction, understating the elapsed time by exactly however much the caller's deadline was tighter than ours.
Now:
ClientErrorCode SdkWrapper::RunWithTimeoutParallel(OpType op_type,
std::vector<std::function<ClientErrorCode()>> &&tasks,
std::chrono::steady_clock::time_point deadline) const {
...
const auto start = std::chrono::steady_clock::now();timeout_ms had no other use in the function, so the parameter is gone rather than left unused. One fewer argument, and the elapsed times in the logs are now real.
…restore tests, simplify APIs Review fixes on top of the deadline_ms passthrough change. Correctness: - hf3fs_usrbio_client: an expired deadline used to pass a null abs_timeout to hf3fs_wait_for_ios, turning the "already out of time" case into an unbounded wait. Now distinguishes "no deadline" (null, legacy behaviour) from "expired" (zero timeout, return immediately). - sdk_wrapper: RunWithTimeoutParallel derived its start time by subtracting the static budget from the deadline, which is wrong once the deadline comes from the caller. Drop the redundant timeout_ms parameter and read the clock. Restored tests dropped by mistake (all from #276, verified to run): - Hf3fsSdkTest.PutBatch_ReturnOk_InterleavedPathsPreserveOrder - LocalFileSdkTest.TestPutMultiPathInterleavedOrdering - TransferClientMultiStorageTest.TestSaveLoadInterleavedMultiPathOrdering API/usability: - deadline_ms defaults to 0 in both the C++ declaration and the pybind signature, so existing callers need no change. - deadline_util: TryRemainingMs -> RemainingMs returning std::optional. vLLM connector: - Read path computes its deadline inside data_transfer.load_task, right after the copy buffer is acquired: only the buffer owner cares about the buffer being touched, and computing it earlier would silently spend the budget on pool queueing. Write path keeps computing it in the worker as close to the start_write_cache response as possible, now hoisted out of the task loop. Also: new test asserting Python time.monotonic_ns() and C++ SteadyClockMs() share one clock, reverted unrelated reformatting, and dropped comments that referenced unpublished documents or stale section numbers.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: dadff14262
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| py::arg("deadline_ms") = 0, | ||
| py::arg("trace_info") = nullptr, |
There was a problem hiding this comment.
Preserve positional trace_info calls
Fresh evidence here is that the new defaulted deadline_ms is inserted before the existing trace_info parameter. Python callers that previously used the old three-positional form LoadKvCaches(uris, buffers, trace_info) (and similarly SaveKvCaches) now have that object bound to deadline_ms, so pybind raises a TypeError before I/O starts; add an overload with the old positional order or make the deadline keyword-only to keep the public API compatible.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Good catch — fixed in 6efd820. You're right that inserting deadline_ms before trace_info silently rebinds the old positional form.
Rather than an overload or keyword-only, I moved deadline_ms to the end of the parameter list, after trace_info, in all three places (C++ declaration, transfer_client_impl, pybind):
virtual ClientErrorCode LoadKvCaches(const UriStrVec &uri_str_vec,
const BlockBuffers &block_buffers,
std::shared_ptr<TransferTraceInfo> trace_info = nullptr,
int64_t deadline_ms = 0) = 0;py::arg("trace_info") = nullptr,
py::arg("deadline_ms") = 0,Both parameters keep defaults, so every legacy positional arity is preserved: LoadKvCaches(uris, buffers), LoadKvCaches(uris, buffers, trace_info), and the new LoadKvCaches(uris, buffers, trace_info, deadline_ms) all bind correctly.
The concrete proof: transfer_client_py_test.py had (in my previous revision) been changed to call LoadKvCaches(uris, buffers, 0). Under this fix I reverted it to the original LoadKvCaches(uris, buffers) from origin/main — byte-identical — and it passes untouched. In-repo connectors that do supply a deadline now pass it by keyword (deadline_ms=...) so ordering can never bite again.
This surfaced under --config=client (which builds the pybind test); it's exactly the class of thing the multi-config check now catches before review.
…l compatibility codex flagged that inserting the defaulted deadline_ms before the existing trace_info parameter silently rebinds it: an out-of-tree caller using the old positional form LoadKvCaches(uris, buffers, trace_info) would bind the TransferTraceInfo to deadline_ms and raise a pybind TypeError before any I/O. Move deadline_ms to the end of the parameter list (after trace_info) in the C++ declaration, the impl, and the pybind signature, so both parameters keep default values and the legacy positional arity is unaffected. In-repo Python callers now pass deadline_ms as a keyword; transfer_client_py_test reverts to the original two-argument form, which is itself the compatibility proof.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 6efd820ca0
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
|
|
||
| for (size_t i = 0; i < groups.size(); ++i) { | ||
| const auto &group = groups[i]; | ||
| tasks.push_back([group, deadline_ms]() { return group.sdk->Get(group.uris, group.buffers, deadline_ms); }); |
There was a problem hiding this comment.
Pass the effective deadline into backend tasks
When callers omit deadline_ms or pass one later than the configured wrapper timeout, this closure still forwards the original unbounded/later value to the backend even though RunWithTimeoutParallel returns at the earlier deadline computed just above. Fresh evidence is that the wrapper now computes the min of the static timeout and caller deadline, but the task ignores that effective deadline; in default public calls that rely on timeout_config, LocalFile/HF3FS/Mooncake therefore keep seeing no/later deadline and can continue per-block/key I/O after the wrapper has returned ER_SDK_TIMEOUT, letting callers reuse buffers while backend work is still accessing them. Convert the effective deadline back to steady-clock milliseconds and pass that to both Get and Put tasks.
Useful? React with 👍 / 👎.
| buffers.append(buffer) | ||
| logger.debug("start transfer") | ||
| transfer_result = self._transfer_client.LoadKvCaches(remote_uris, buffers) | ||
| transfer_result = self._transfer_client.LoadKvCaches(remote_uris, buffers, deadline_ms=deadline_ms) |
There was a problem hiding this comment.
Quarantine vLLM copy buffers after Mooncake timeouts
When this call returns ER_SDK_TIMEOUT from a Mooncake backend, the backend's soft contract still allows earlier per-key RDMA to be writing into the supplied buffers; this function then skips scatter but unconditionally returns copy_buffer_indices to the allocator below. In vLLM deployments using Mooncake, a later task can reuse those CPU copy buffers while stale RDMA is still active, corrupting the next transfer despite the new deadline. On soft-backend timeouts, keep these buffers quarantined until the backend is known safe or avoid issuing soft timeouts on this path.
Useful? React with 👍 / 👎.
Resolve conflicts between deadline plumbing (#280) and main: - sdk_factory.h: keep virtual CreateSdk (test fake injection) and add private static CreateSdkInstance from #293 (TairMempool SSD split) - sdk/test/BUILD: keep both SdkFactoryTest and SdkTimeoutContractTest - sdk_wrapper_test.cc: union of includes (shm tests from #288 + deadline tests from #280)
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b59ba2ee92
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| const BlockBuffers &block_buffers, | ||
| std::shared_ptr<TransferTraceInfo> trace_info = nullptr) = 0; | ||
| std::shared_ptr<TransferTraceInfo> trace_info = nullptr, | ||
| int64_t deadline_ms = 0) = 0; |
There was a problem hiding this comment.
Preserve the existing TransferClient virtual ABI
When an existing C++ client loads the updated public kv_cache_manager_client.so without being recompiled, its virtual call still passes only the old arguments; default arguments are applied only during compilation, so the new implementation reads an unspecified register as deadline_ms and may reject I/O immediately or use an arbitrary deadline. The same ABI mismatch affects SaveKvCaches; retain the old virtual entry points and add deadline-aware overloads, or otherwise require and document a coordinated rebuild.
Useful? React with 👍 / 👎.
…rallel Dropping the shared stop flag and the drain loop together (3478d60) silently widened the "writes into caller buffer after return" window from the timeout path to the plain-error path: - on a plain error (deadline NOT yet reached) the wrapper returned immediately while in-flight peer groups kept doing I/O into the caller's buffers; the caller typically reuses/frees buffers on error return, racing the in-flight DMA of another (hard) backend; - groups still queued behind the failed one were no longer intercepted by anything: the time-based admission in the wrapped task only fires once the deadline has passed, so they started fresh I/O after the caller had already gotten its error back. origin/main guarded both with stop + wait_until(deadline) (a bounded drain); the timeout path keeps its immediate-return semantics because at that point the deadline has passed and wait_until is a no-op. Restore that structure, keeping the deadline-based wait and logs: - wrapped task: short-circuit on stop OR expired deadline; - drain(from): set stop (queued tasks never start I/O) and wait the remaining futures at most until the deadline; - error path drains before returning the error; timeout path drains too (no-op wait) for the stop side effect. Tests (SdkTimeoutContractTest, all timing-loose, no sleep-sync): - TestErrorPathWaitsForInFlightPeer: fast-failing group + 400ms peer with a 3s deadline -> error is returned only after the peer finished (elapsed >= 350ms); - TestErrorPathDrainIsBoundedByDeadline: peer sleeps 3s, deadline 150ms -> return happens at the deadline, never at the peer's completion; - TestErrorPathStopsQueuedTasks: single pool thread + blocker, three queued groups (err -> 500ms gap -> tail); the gap task guarantees the stop flag is observed before tail is ever picked up, so tail's I/O provably never starts (get_call_count == 0) with ~9s left on the deadline (i.e. the time-based admission cannot be what stopped it). Also update contract doc section 4.4, which previously described the error-path no-drain behaviour as a pre-existing origin/main issue (it was not: main drained peers on error), and fix the misleading comment in TestRunningTimeoutBoundedReturn that equated any drain with an unbounded wait.
SdkWrapper::Get/Put computed min(internal budget, caller deadline) for
their own wait bound but handed the RAW caller deadline to the SDK
tasks. The two could diverge in both directions:
- caller deadline later than the internal budget: the wrapper times
out and returns at the internal budget while background tasks keep
doing I/O into the caller's buffers until the (later) caller
deadline -- the exact "writes into caller buffer after return"
window this PR exists to close, just with the wait bound and the
admission check decoupled;
- caller deadline == 0 ("unspecified", the legacy default): SDKs
received 0, so DeadlineExpired() is never true and every per-block
/ per-key admission check was inert; the "falls back to the static
client timeout budget" promise in transfer_client.h only held at
the wait layer, not at the buffer layer.
Thread the effective deadline (SteadyClockMs-based, same clock as
deadline_util.h) into the SDK tasks and derive the wait-bound
time_point from the same value, so the wrapper's wait ceiling and the
SDK's admission reference are always the same instant. deadline_ms=0
now means "the internal budget is the deadline" at every layer, which
is what the interface docs already claimed.
In-tree connectors are unaffected in practice: they pass
sdk_get/put_timeout_ms, the same source the internal budget comes
from, so min == caller.
Tests:
- TestSdkReceivesMinOfInternalAndCallerDeadline: internal budget
300ms, caller 100000ms -> SDK observes exactly now+300;
- TestZeroCallerDeadlineStillPassesInternalBudget: caller 0 -> SDK
observes a real (non-zero) deadline equal to the internal budget,
proving per-block admission is armed for legacy callers;
- TestDeadlinePropagation now uses a caller deadline clearly below
the fixture's internal budget, so "SDK sees exactly the caller's
value" is the tested min semantics instead of an artifact of the
caller timestamp being taken first.
Contract doc section 2 updated to state that the min result is what
is handed to the SDKs.
…e fails
The invariant documented right above the branch says an expired
deadline must turn into a zero timeout ("passing nullptr becomes an
unbounded wait"), yet the clock_gettime(CLOCK_REALTIME) failure branch
did exactly that: abs_timeout_ptr = nullptr, i.e. hf3fs_wait_for_ios
waits forever while the caller is already past its deadline.
Treat a failed clock_gettime as "expired" (zero timeout) so a bounded
caller stays bounded on every path. clock_gettime(CLOCK_REALTIME) is
unconditionally supported and does not fail in practice; this is pure
defence-in-depth for the stated invariant.
Test: TestWaitIosExpiredDeadlinePassesZeroTimeoutNotNullptr pins the
zero-timeout construction (non-null abs, at/before the entry wall
clock) -- previously this shape also covered the failure branch's
nullptr fallback, which the mock would have caught as
captured_null == true.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 0bd2ebfde2
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| } else { | ||
| ReleaseIovIor(handle); |
There was a problem hiding this comment.
Preserve HF3FS resources on every incomplete wait
When Hf3fsWaitForIos returns before all submitted requests complete, this branch releases the iov/IOR unless a separate monotonic-clock check already reports expiration. That does not prove the requests are drained: WaitIos explicitly permits an incomplete non-timeout result, and its absolute timeout uses CLOCK_REALTIME, so a forward wall-clock adjustment can trigger it while the monotonic deadline remains unexpired. The remaining requests can then access freed resources; the same conditional release occurs on the write path. Fresh evidence in this revision is that the leak safeguard is conditioned on DeadlineExpired() rather than on whether all submitted I/O was actually completed, so preserve these resources whenever an incomplete wait may leave requests in flight.
Useful? React with 👍 / 👎.
| // 契约要求(docs/design/client_sdk_io_contract.md HF3FS 行):CopyIovs 只在读取完全成功时执行, | ||
| // 超时/部分完成/出错时一律不把半成品数据交付给 caller。 | ||
| // 顺序不可调整,防止后人"优化"时把 CopyIovs 挪到失败分支之前。 | ||
| CopyIovs(iovs, handle->iov_handle, true); |
There was a problem hiding this comment.
Recheck the deadline before copying staged HF3FS reads
When the 3FS wait consumes the remaining budget but still reports all requests complete, execution reaches CopyIovs without another deadline admission check. The wrapper can therefore return ER_SDK_TIMEOUT at the deadline while this background task starts copying the staged data into the caller's CPU/GPU buffer afterward, defeating the reason HF3FS is classified as a hard backend. Check the deadline after ReadFrom3FS succeeds and before beginning the caller-buffer copy so an I/O completion at the deadline is not treated as permission to start a new transfer phase.
Useful? React with 👍 / 👎.
| CHECK_SDK(); | ||
| auto remote_uris = ParseLocations(uri_str_vec); | ||
| auto ec = sdk_wrapper_->Get(remote_uris, block_buffers); | ||
| auto ec = sdk_wrapper_->Get(remote_uris, block_buffers, deadline_ms); |
There was a problem hiding this comment.
Enforce deadlines around SDK buffer checks
When KVCM_SDK_CHECK is enabled and the backend completes near the deadline, this call can return ER_OK and then spend additional time acquiring a check cell and hashing every caller buffer in GetBlocksHash. No deadline check surrounds that diagnostic work, so LoadKvCaches can continue reading the caller's CPU/GPU buffers and return success after the advertised absolute deadline; the pre-write hash has the same buffer-lifetime issue. Skip or bound these checks once deadline_ms expires so optional diagnostics do not violate the public I/O contract.
Useful? React with 👍 / 👎.
3478d60 dropped the actual_remote_uris position-correspondence assertions from TestMultipleUriWithCpu (and its GPU twin) while mechanically adding the deadline_ms parameter; dadff14 restored three other accidentally-dropped tests but missed these. The same PR added a "same-order contract" comment in mooncake_sdk.cc -- the test enforcing it should not be missing. Alloc is an identity assignment today, so the assertions hold; their value is guarding future refactors of Alloc into per-item backfill (which must preserve position correspondence by index, exactly like LocalFileSdk::Put does). Byte-identical to the assertions as they existed on origin/main (from 36c4875). MooncakeSdkTest is a manual target needing a live mooncake master (localhost:50051); verified it compiles with --config=mooncake_common.
LogSoftTimeout formatted is_get ? "get" : "put" twice (once as "mooncake %s timeout" and once as "op=%s"), printing the same value twice on every line. Keep the single mooncake-prefixed field. Log format change only; callers and fields otherwise untouched.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: bdbb11a592
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| // 逐 block 准入:deadline 已过则停止搬运,不再发起 | ||
| // 后续 memcpy/async copy。若已有 GPU async copy 入队,guard 析构会在返回前同步。 | ||
| if (DeadlineExpired(deadline_ms)) { |
There was a problem hiding this comment.
Bound the final flush before declaring a write deadline-safe
When the last LocalFile/NFS block is admitted just before the deadline, the loop can finish its memcpy and then execute the unconditional msync(..., MS_SYNC) after the wrapper has already returned ER_SDK_TIMEOUT. On a slow filesystem, that flush may continue persisting stale pages after KVCM has expired and reassigned the URI, defeating the write-lease protection added by this change; the durability phase must be included in the deadline/cancellation design rather than guarding only block admission.
Useful? React with 👍 / 👎.
Per-task torch.empty staging made the connector's VRAM footprint unbounded and racing the engine for its own allocation: at high load on low-headroom GPUs (doc085 @ gpu-mem-util 0.92) the 98 MiB per load task allocations OOMed the engine where origin/main -- whose gather kernel writes a pre-allocated pinned host pool directly -- ran fine. Replace the dynamic allocs with one _StagingPool per transfer group (save and load share it): a fixed, configurable HBM + pinned reservation (staging_pool_blocks, default 128, validated >= the largest task batch) handed out as contiguous runs, with blocking acquire as backpressure when exhausted. Bulk D2H/H2D and kernel views are unchanged -- only the buffer source differs. Exception paths drain the stream before the slots go back so a failed task cannot leave enqueued work against a reused view. This also restores origin/main's no-dynamic-allocation invariant the revert of _PinnedBudget (7bd0413) dropped, without reintroducing the connector-level lifetime governance that #280's deadline chain owns.
…path The pool fix (a0d6289) bounded the connector's staging but kept a device-side mirror per transfer group: staging_pool_blocks x per_block_bytes of permanently reserved HBM (112 MiB/group at the default on Qwen2.5-7B TP1, once per hybrid group) that competes with the engine's KV cache and batch headroom -- exactly the reviewer concern on low-end cards. The mirror only existed to route transfers through a bulk D2H/H2D copy, and that copy is not needed: the strided gather/scatter kernel addresses host pinned memory directly over PCIe (UVA zero-copy, as in origin/main), and state-group copies are plain copy_ between the GPU tensors and the pinned slices. Make _StagingPool pinned-host-only and delete the GPU buffer, the gpu_view, and both bulk copies. Save gathers HBM -> pinned slot and hands the slot to SaveKvCaches; load hands the slot to LoadKvCaches and scatters pinned -> HBM. Backpressure, contiguous-run allocation, exception-path stream drains and the capacity guard are unchanged; the kernel and the scheduler/worker instruction flow are untouched. staging_pool_blocks now sizes host RAM only. Trade-off: a slot is reused once its task reports, so after an SDK timeout/error a background DMA may still scribble on a reused slot -- the same exposure origin/main's CopyBufferAllocator always had. The proper fix is #280's deadline contract; taking the exposure now is what buys zero VRAM without delaying this PR.
Summary
This PR gives KV cache reads and writes an absolute deadline that the storage backends actually honor: once the deadline passes, no layer in the client (connector →
TransferClient→SdkWrapper→ storage SDK) is allowed to keep touching the caller's pinned buffer or keep writing to remote storage.The problem it fixes is a class of silent corruption: today
SdkWrapper::Get/Putcan return (on timeout, or on a peer group's error) while a storage backend is still DMA-ing into the caller's buffer. The connector then reuses that buffer slot and the in-flight transfer scribbles over the new tenant. On the write side the symmetric problem is worse in a different way — the client can keep writing to remote URIs after KVCM has already expired and reclaimed the lease.The mechanism is one
int64_t deadline_ms(absoluteCLOCK_MONOTONICmilliseconds,0= "no caller deadline") threaded from the connector down to each backend, plus deadline checks at two granularities: an admission gate inSdkWrapper, and a per-block/per-key check inside every backend.Motivation: why an unbounded timeout is unnatural
If you hand a slab of your memory to an RDMA/storage engine and don't bound how long it may write into it, the engine may keep writing for as long as it likes — there is no rule stopping it. That is the root defect. The contract this PR establishes is simple and stated at the interface:
This is a property of buffer ownership, so it belongs at the layer that owns the buffer, and the deadline is computed by whoever allocated it. The two paths need this for related-but-different reasons.
Read path
The only risk is a storage SDK/client still writing into the connector's pinned buffer after we've returned. The buffer belongs to the transfer layer (in vLLM,
data_transfer.load_taskallocates it viaCopyBufferAllocator), so the deadline is computed there, right after the buffer is acquired — not earlier in the connector, which would waste budget on pool queueing before the buffer even exists. The contract with the storage client is exactly "don't touch my buffer afterT".Write path
Two things are layered on the write path:
T; afterTI no longer guarantee its contents are stable."deadline_mswe pass now carries this meaning too, which is a significant hardening.The most concrete win: for a task that has been queued too long in the transfer client's thread pool,
origin/mainwould still issue a dirty write to remote storage. The deadline check shrinks that window dramatically — a task that missed its deadline while queued is rejected before it issues any I/O.Because a monotonic timestamp is meaningless across hosts, the deadline is always computed in the process that uses it and never broadcast from rank 0. Cross-machine clock skew would corrupt the happy path (a skewed worker refusing all I/O); lease overrun only bites off the happy path. See Known Limitations.
A note on PACE (internal, not open-sourced)
The
TairMempoolbackend is PACE, a staging design. PACE's internal timeout is ~9–10s and KVCM's external timeout was 15s, so PACE always timed out first and theSdkWrappertimeout came second. It happened to work, but it depended entirely on that implicit "PACE < wrapper" ordering — extremely fragile. Passing an explicit deadline makes the ordering explicit rather than incidental. (Separately, all staging designs share a "reserved blocks all gone → backend effectively dead" failure mode; that is out of scope here.)What changed, by layer
Deadline plumbing (
deadline_util.h) —SteadyClockMs(),DeadlineExpired(deadline_ms), andstd::optional<int64_t> RemainingMs(deadline_ms). A C++ test asserts Pythontime.monotonic_ns()//1_000_000and C++SteadyClockMs()are the same clock, so the cross-language comparison is enforced rather than assumed.SdkWrapper— admission gate.Get/Puttakemin(internal static budget, caller deadline); thisminis taken in exactly one place, so connectors never reason about the internal 15s. Tasks whose deadline already passed while queued in the thread pool are rejected before issuing I/O — this is the piece that shrinks the "dirty write after long queueing" window.Per-backend, per-block/key checks:
GpuStreamDrainGuardcudaStreamSynchronizes before returning, so no async copy can still land in the caller buffer after return. This is a real cancel.CopyIovs. usrbio has no cancel, so we leak the submitted iov/IOR rather than free them (freeing would be a UAF). Bounded leak > corruption.LogSoftTimeoutline (which blocks were issued vs. completed). Declaredsoftin the contract matrix.Connectors — vLLM (read deadline in
data_transfer.load_taskafter buffer alloc; write deadline in the worker, hoisted out of the task loop, anchored to thestart_write_cacheresponse), trtllm (same shape — it previously passed no dynamic deadline and leaned only on the C++ static 15s; it now computes a real per-call deadline in the worker), sglang (per-worker computation, no cross-host broadcast). Shared helpercommon/utils.deadline_ms_from_now.API compatibility —
deadline_msdefaults to0and is placed aftertrace_info, so every legacy positional call (LoadKvCaches(uris, buffers)/(uris, buffers, trace_info)) is unchanged. In-repo callers pass it by keyword.Changes
client/src/internal/sdk/deadline_util.hSteadyClockMs/DeadlineExpired/RemainingMs(optional)client/src/internal/sdk/sdk_wrapper.{h,cc}min(budget, deadline)in one place; admission gate; drop bogustimeout_ms-derived startclient/src/internal/sdk/sdk_interface.hdeadline_msonGet/Putclient/src/internal/sdk/local_file_sdk.{h,cc}GpuStreamDrainGuardhard cancel on GPU abortclient/src/internal/sdk/hf3fs_{sdk,usrbio_client}.{h,cc}abs_timeout; expired-deadline no longer waits forever; leak iov/IOR on timeoutclient/src/internal/sdk/mooncake_sdk.{h,cc}client/include/transfer_client.h,client/pybind/py_client_binding.cc,client/src/transfer_client_impl.{h,cc},client/src/manager_client_impl.ccdeadline_ms(defaulted, aftertrace_info) through the public APIpy_connector/common/utils.pydeadline_ms_from_nowshared helperpy_connector/vllm/{v1_connector,data_transfer}.pypy_connector/trtllm/connector.py,py_connector/sglang/connector.pyopen_source/.../tair_mempool_sdk.{h,cc},stub_source/...deadline_msdocs/design/client_sdk_io_contract.mdclient/src/internal/sdk/test/*,client/test/transfer_client_test.ccKnown Limitations / Risks
Full text in
docs/design/client_sdk_io_contract.md §4. The important ones:StartWriteCache; the client only learns the result after an RPC round-trip. Communication always takes time, so the client's clock origin is inherently later than the server's. This gap is uncloseable from the client side and is explicitly accepted, not worked around.ManagerClient/RTPLLMClientpass no deadline (0), preserving pre-PR static-budget behavior for those control-plane paths.Get/Putreturns immediately. Pre-existing behavior; the buffer guarantee rests on the deadline contract, not on draining peers.soft: reusing a buffer after a Mooncake timeout is a documented data race.Testing
Verified under four configurations — the multi-config sweep exists because the default build is opt-out of Mooncake/HF3FS and had previously let compile errors escape to review:
--config=client--config=asan--config=cuda(real 2×A10)SdkBufferCheckUtilTest, GPU abort pathKey cases:
SdkTimeoutContractTest— expired deadline returns immediately; "stopped midway" mid-transfer state; per-block admission; a fast backend's data stays usable while a slow peer times out;TestPythonAndCppShareSteadyClock(clock identity).LocalFileSdkTest.TestGpuAbortPathDrainsStream— on A10, asserts the stream is drained before return (no in-flight DMA into the caller buffer).OK (102 ms).All Python connector call sites
py_compile-clean; legacy two-arg positional API confirmed unchanged by revertingtransfer_client_py_test.pyto its pre-PR form and passing it untouched.